package org.geoscrape;

import java.io.Serializable;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

/**
 * Implements a lat/lon pair in the WGS84 datum.
 * 
 * 
 */
public class Location implements Serializable
{
	private static final long serialVersionUID = -2374475574650579871L;

	// used in calculating distance between two locations.
	private static final double EQUATORIALRADIUS = 6378137.0;

	// point separated floating point number less than 100 and max 3 decimal
	// digits:
	private static final String number = "[0-9]{1,2}\\.{0,1}[0-9]{0,3}";
	private static final Pattern STD_PATTERN = 
			Pattern.compile("[NS]{0,1}[ ]{0,1}[0-9]{1,2} " + number + " [EW]{0,1}[ ]{0,1}[0-9]{1,3} " + number);

	// point separated positive or negative floating point number less than 1000
	// and any number of decimal digits
	private static final String longnumber = "-{0,1}[0-9]{1,3}\\.{0,1}[0-9]{0,}";
	private static final Pattern DECIMAL_PATTERN = Pattern.compile(longnumber + "[\\, ]{1,2}" + longnumber);

	private static final Pattern DECIMAL_WITH_NS_PATTERN = 
			Pattern.compile("[NS]{0,1}[ ]{0,1}" + longnumber +" [EW]{0,1}[ ]{0,1}" + longnumber);
	
	private static final String dms = "[0-9]{1,3} [0-9]{1,2} [0-9]{1,2}\\.{0,1}[0-9]{0,}";
	private static final Pattern DMS_PATTERN = Pattern.compile("[NS]{0,1}[ ]{0,1}"+dms+" [EW]{0,1}[ ]{0,1}"+dms);

	private Coordinate latitude;
	private Coordinate longitude;

	public Location()
	{

	}

	public Location(Location other)
	{
		setLatitude(new Coordinate(other.getLatitude()));
		setLongitude(new Coordinate(other.getLongitude()));
	}

	public Location(Coordinate latitude, Coordinate longitude)
	{
		setLatitude(latitude);
		setLongitude(longitude);
	}

	/**
	 * Parse coordinates from a string.
	 * 
	 * In the following, D = degree, M = minute, s = second, E,W,N,S = compass
	 * directions. All degree (°), minute ('), and second (") symbols are
	 * voluntary and will be ignored.
	 * 
	 * Formats that have compass direction letters may only omit them if the
	 * coordinates are exactly at zero, e.g. on the prime meridian or on the
	 * equator.
	 * 
	 * Allowed format are: 
	 * 
	 * Standard: [N|S] DD° MM.MMM [E|W] DD° MM.MMM 
	 * 
	 * Decimal with N/S and E/W designation: [N|S] DD.D* [E|W] DD.D* 
	 * 
	 * Decimal: [-]DD.D* [-]DD.D* 
	 * 
	 * DMS: [N|S] DD° MM' ss.ss" [E|W] DD° MM' ss.ss"
	 * 
	 * @param wsg84coords
	 */
	public Location(String wsg84coords)
	{
		// convert to uppercase
		wsg84coords = wsg84coords.toUpperCase();
		//remove the minute symbol
		wsg84coords = wsg84coords.replaceAll("′", "");
		// remove the degree symbol
		wsg84coords = wsg84coords.replaceAll("°", "");
		wsg84coords = wsg84coords.replaceAll("\u00B0", "");
		wsg84coords = wsg84coords.replaceAll("\u2218", "");
		//insert extra space after N,S,W,and E.
		wsg84coords = wsg84coords.replaceAll("N", "N ");
		wsg84coords = wsg84coords.replaceAll("S", "S ");
		wsg84coords = wsg84coords.replaceAll("W", "W ");
		wsg84coords = wsg84coords.replaceAll("E", "E ");
		// remove minute symbol
		wsg84coords = wsg84coords.replaceAll("\'", "");
		// remove second symbol
		wsg84coords = wsg84coords.replaceAll("\"", "");
		// remove tabs
		wsg84coords = wsg84coords.replaceAll("\t", " ");
		// remove commas
		wsg84coords = wsg84coords.replaceAll(",", " ");
		// remove double spaces
		wsg84coords = wsg84coords.replaceAll(" +", " ");
		// remove trailing or leading spaces
		wsg84coords = wsg84coords.trim();

		String[] parts = wsg84coords.split(" ");

		Matcher m = STD_PATTERN.matcher(wsg84coords);
		if (m.matches())
		{
			initFromStandardFormat(parts);
			return;
		}
		m = DECIMAL_PATTERN.matcher(wsg84coords);
		if (m.matches())
		{
			initFromDecimalFormat(parts);
			return;
		}
		m = DECIMAL_WITH_NS_PATTERN.matcher(wsg84coords);
		if (m.matches())
		{
			initFromExtendedDecimalFormat(parts);
			return;
		}
		m = DMS_PATTERN.matcher(wsg84coords);
		if(m.matches())
		{
			initFromDMSFormat(parts);
			return;
		}
		throw new IllegalArgumentException(wsg84coords +" is not a valid set of coordinates.");
	}

	/**
	 * @param parts
	 */
	private void initFromDMSFormat(String[] parts)
	{
		boolean neg = false;
		int startIndex = 1;
		if(Character.isDigit(parts[0].charAt(0)))
		{
			startIndex = 0;
		}
		else
		{
			if(parts[0].equals("S"))
			{
				neg = true;
			}
		}
		int deg = Integer.parseInt(parts[startIndex]);
		startIndex++;
		int min = Integer.parseInt(parts[startIndex]);
		startIndex++;
		double secs = Double.parseDouble(parts[startIndex]);
		secs/=60;//convert to minutes
		int fracMin = (int)Math.floor(secs*1000);//convert to thousands of a minute
		setLatitude(new Coordinate(deg,min,fracMin));
		getLatitude().setNegative(neg);
		
		neg = false;
		startIndex++;
		if(Character.isLetter(parts[startIndex].charAt(0)))
		{
			if(parts[startIndex].equals("W"))
			{
				neg = true;
			}
			startIndex++;
		}
		deg = Integer.parseInt(parts[startIndex]);
		startIndex++;
		min = Integer.parseInt(parts[startIndex]);
		startIndex++;
		secs = Double.parseDouble(parts[startIndex]);
		secs/=60;//convert to minutes
		fracMin = (int)Math.floor(secs*1000);//convert to thousands of a minute
		setLongitude(new Coordinate(deg,min,fracMin));
		getLongitude().setNegative(neg);
	}

	/**
	 * @param parts
	 */
	private void initFromExtendedDecimalFormat(String[] parts)
	{
		boolean neg = false;
		int startIndex = 1;
		if (Character.isDigit(parts[0].charAt(0)))
		{
			// no N/S letter
			startIndex = 0;
		}
		else
		{
			if(parts[0].equals("S"))
			{
				neg = true;
			}
		}
		double north = Double.parseDouble(parts[startIndex]);
		setLatitude(new Coordinate(north));
		getLatitude().setNegative(neg);

		neg = false;
		startIndex++;
		if (!Character.isDigit(parts[startIndex].charAt(0)))
		{
			//there is an E/W letter
			if(parts[startIndex].equals("W"))
			{
				neg = true;
			}
			startIndex++;
		}
		double east = Double.parseDouble(parts[startIndex]);
		setLongitude(new Coordinate(east));
		getLongitude().setNegative(neg);
		
	}

	/**
	 * @param parts
	 */
	private void initFromDecimalFormat(String[] parts)
	{
		double north = Double.parseDouble(parts[0]);
		double east = Double.parseDouble(parts[1]);
		setLatitude(new Coordinate(north));
		setLongitude(new Coordinate(east));
	}

	/**
	 * Handle coordinates on this format: <N|S> DD MM[.MMM] <E|W> DD MM[.MMM]
	 * 
	 * @param parts
	 */
	private void initFromStandardFormat(String[] parts)
	{
		int startOffset = 1;
		// handle special cases where north/south coordinates are zero
		if (Character.isDigit(parts[0].charAt(0)))
		{
			startOffset--;
		}
		int latDeg = Integer.parseInt(parts[startOffset]);
		String[] latMins = parts[startOffset + 1].split("\\.");
		int latMin = Integer.parseInt(latMins[0]);
		int latMinFrac = 0;
		if (latMins.length > 1)
		{
			latMinFrac = Integer.parseInt(latMins[1]);
		}
		boolean negative = false;
		if (parts[0].equals("S"))
		{
			latDeg = -latDeg;
			negative = true;
		}
		setLatitude(new Coordinate(latDeg, latMin, latMinFrac));
		getLatitude().setNegative(negative);

		// handle special cases where east/west coordinates are zero
		if (Character.isDigit(parts[startOffset + 2].charAt(0)))
		{
			startOffset--;
		}
		int lonDeg = Integer.parseInt(parts[startOffset + 3]);
		String[] lonMins = parts[startOffset + 4].split("\\.");
		int lonMin = Integer.parseInt(lonMins[0]);
		int lonMinFrac = 0;
		if (lonMins.length > 1)
		{
			lonMinFrac = Integer.parseInt(lonMins[1]);
		}
		negative = false;
		if (parts[startOffset + 2].equals("W"))
		{
			lonDeg = -lonDeg;
			negative = true;
		}
		setLongitude(new Coordinate(lonDeg, lonMin, lonMinFrac));
		getLongitude().setNegative(negative);
	}

	/**
	 * Get the location based on WSG84 rational coordinates.
	 * 
	 * @param d
	 * @param e
	 */
	public Location(double north, double east)
	{
		setLatitude(new Coordinate(north));
		setLongitude(new Coordinate(east));
	}

	public String toString()
	{
		StringBuilder res = new StringBuilder(30);
		if (latitude.isNegative())
		{
			res.append("S ");
		}
		else
		{
			res.append("N ");
		}
		if (Math.abs(latitude.getDegree()) < 10)
		{
			res.append("0");
		}
		res.append(latitude.toString());
		res.append(" ");
		if (longitude.isNegative())
		{
			res.append("W ");
		}
		else
		{
			res.append("E ");
		}
		if (Math.abs(longitude.getDegree()) < 100)
		{
			res.append("0");
		}
		if (Math.abs(longitude.getDegree()) < 10)
		{
			res.append("0");
		}
		res.append(longitude.toString());
		return res.toString();
	}

	/**
	 * @return the latitude
	 */
	public Coordinate getLatitude()
	{
		return latitude;
	}

	/**
	 * @param latitude
	 *            the latitude to set
	 */
	public void setLatitude(Coordinate latitude)
	{
		this.latitude = latitude;
	}

	/**
	 * @return the longitude
	 */
	public Coordinate getLongitude()
	{
		return longitude;
	}

	/**
	 * @param longitude
	 *            the longitude to set
	 */
	public void setLongitude(Coordinate longitude)
	{
		this.longitude = longitude;
	}

	/**
	 * Get the x (longitude) coordinate used in cache map search.
	 * 
	 * @param zoom
	 *            the current zoom level.
	 * @return
	 */
	public int getLongitudeGsJson(int zoom)
	{
		double angle = getLongitude().getDegreeWithFraction();
		angle += 180;
		double max = Math.pow(2, zoom);
		int index = (int) Math.floor(max * angle / 360.0);
		return index;
	}

	/**
	 * Get the y (latitude) coordinate used in cache map search.
	 * 
	 * @param zoom
	 *            the current zoom level.
	 * @return
	 */
	public int getLatitudeGsJson(int zoom)
	{
		double angle = getLatitude().getDegreeWithFraction();
		double phi = Math.toRadians(angle);
		double y = Math.log((Math.sin(phi) + 1) / Math.cos(phi));
		y = Math.PI - y;
		return (int) Math.floor(Math.pow(2, zoom) * y / (2 * Math.PI));
	}

	/**
	 * Calculate the spherical distance between two GeoCoordinates in meters
	 * using the Haversine formula
	 * 
	 * This calculation is done using the assumption, that the earth is a
	 * sphere, which is not the case. If you need a higher precision and can
	 * afford a longer execution time you might want to use Vincenty distance
	 * 
	 * @param other
	 * 
	 * @return the distance in meters.
	 */
	public double distance(Location other)
	{
		double lat1 = this.getLatitude().getDegreeWithFraction();
		double lat2 = other.getLatitude().getDegreeWithFraction();
		double lon1 = this.getLongitude().getDegreeWithFraction();
		double lon2 = other.getLongitude().getDegreeWithFraction();
		double dLat = Math.toRadians(lat2 - lat1);
		double dLon = Math.toRadians(lon2 - lon1);
		double a = Math.sin(dLat / 2) * Math.sin(dLat / 2) + Math.cos(Math.toRadians(lat1))
				* Math.cos(Math.toRadians(lat2)) * Math.sin(dLon / 2) * Math.sin(dLon / 2);
		double c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
		return c * EQUATORIALRADIUS;
	}
	
	/**
	 * Calculate the bearing along the great circle from this location to the other location.
	 * This is sometimes called the forward azimuth. 
	 * 
	 * @param other
	 * @return the bearing in degrees.
	 */
	public double bearing(Location other)
	{
		double lat1 = Math.toRadians(this.getLatitude().getDegreeWithFraction());
		double lon1 = Math.toRadians(this.getLongitude().getDegreeWithFraction());
		double lat2 = Math.toRadians(other.getLatitude().getDegreeWithFraction());
		double lon2 = Math.toRadians(other.getLongitude().getDegreeWithFraction());
		double dLon = lon2-lon1;
		double y = Math.sin(dLon)*Math.cos(lat2);
		double x = Math.cos(lat1)*Math.sin(lat2) - Math.sin(lat1)*Math.cos(lat2)*Math.cos(dLon);
		double course = Math.toDegrees(Math.atan2(y, x));
		if(course<0)
		{
			course+=360;
		}
		return course%360.0;
	}

	/**
	 * Two locations are equal if they refer to exactly the same place on the surface of the Earth.
	 * 
	 * @see java.lang.Object#equals(java.lang.Object)
	 */
	@Override
	public boolean equals(Object obj)
	{
		if(obj instanceof Location)
		{
			Coordinate otherLat = ((Location)obj).getLatitude();
			Coordinate otherLon = ((Location)obj).getLongitude();
			if(otherLat.equals(getLatitude())&&otherLon.equals(getLongitude()))
			{
				return true;
			}
		}
		return false;
	}
	
	
}
